Node.js CLI 脚手架:create 命令实现指南
概述
本文档详细介绍了如何构建一个功能完整的 Node.js CLI 脚手架工具,重点讲解 create 命令的实现原理、架构设计和最佳实践。通过本指南,你将学会如何创建一个支持项目模板选择、依赖管理、代码生成等功能的现代化脚手架工具。
项目架构
整体架构图
text
┌─────────────────────────────────────────────────────────────┐
│ CLI 脚手架工具 │
├─────────────────────────────────────────────────────────────┤
│ CLI 入口 (bin/cli.js) │
├─────────────────────────────────────────────────────────────┤
│ 命令解析器 (yargs/commander) → create 命令处理器 │
├─────────────────────────────────────────────────────────────┤
│ 核心功能层 │
│ ├── 模板管理 (NpmPackage 类) │
│ ├── 用户交互 (@inquirer/prompts) │
│ ├── 文件操作 (fs-extra) │
│ ├── 代码渲染 (ejs) │
│ └── 进度显示 (ora) │
├─────────────────────────────────────────────────────────────┤
│ 工具层 │
│ ├── 路径处理 (path) │
│ ├── 系统信息 (os) │
│ └── 异步处理 │
└─────────────────────────────────────────────────────────────┘项目结构
bash
guang-cli/
├── packages/
│ ├── cli/ # CLI 入口包
│ │ ├── bin/
│ │ │ └── cli.js # 可执行文件
│ │ ├── src/
│ │ │ └── index.ts # CLI 主程序
│ │ └── package.json
│ ├── create/ # create 命令实现
│ │ ├── src/
│ │ │ └── index.ts # create 命令核心逻辑
│ │ └── package.json
│ ├── utils/ # 工具包
│ │ ├── src/
│ │ │ └── npm-package.ts # NPM 包管理类
│ │ └── package.json
│ ├── template-react/ # React 模板
│ │ ├── template/ # 模板文件目录
│ │ ├── questions.json # 模板配置
│ │ └── package.json
│ └── template-vue/ # Vue 模板
│ ├── template/ # 模板文件目录
│ └── package.json
├── pnpm-workspace.yaml # pnpm 工作区配置
└── package.json核心功能
1. 模板选择系统
typescript
// 模板配置定义
interface TemplateChoice {
name: string // 显示名称
value: string // 模板包名
description?: string // 描述信息
}
// 模板选择实现
const projectTemplate = await select({
message: "请选择项目模版",
choices: [
{
name: "React 项目",
value: "@guang-cli/template-react",
description: "基于 Vite 的 React 项目模板"
},
{
name: "Vue 项目",
value: "@guang-cli/template-vue",
description: "基于 Vite 的 Vue 项目模板"
}
]
})2. 项目命名验证
typescript
// 项目名验证逻辑
let projectName = ""
while (!projectName) {
projectName = await input({
message: "请输入项目名",
validate: (value) => {
if (!value.trim()) {
return "项目名不能为空"
}
if (!/^[a-zA-Z0-9-_]+$/.test(value)) {
return "项目名只能包含字母、数字、连字符和下划线"
}
return true
}
})
}3. 目录冲突处理
typescript
// 目录存在性检查和冲突解决
const targetPath = path.join(process.cwd(), projectName)
if (fse.existsSync(targetPath)) {
const stats = await fse.stat(targetPath)
if (stats.isDirectory()) {
const files = await fse.readdir(targetPath)
if (files.length > 0) {
const shouldEmpty = await confirm({
message: `目录 ${projectName} 不为空,是否清空?`
})
if (shouldEmpty) {
await fse.emptyDir(targetPath)
} else {
console.log("操作已取消")
process.exit(0)
}
}
} else {
console.error(`错误: ${projectName} 已存在且不是目录`)
process.exit(1)
}
}create 命令实现原理
整体流程
图表渲染中…
核心实现代码
typescript
// create 命令核心实现
import { select, input, confirm } from "@inquirer/prompts"
import os from "node:os"
import { NpmPackage } from "@guang-cli/utils"
import path from "node:path"
import ora from "ora"
import fse from "fs-extra"
import ejs from "ejs"
import { glob } from "glob"
export async function create() {
try {
// 1. 选择项目模板
const projectTemplate = await selectTemplate()
// 2. 获取项目名
const projectName = await getProjectName()
// 3. 处理目录冲突
const targetPath = await handleDirectoryConflict(projectName)
// 4. 下载/更新模板
const templatePkg = await downloadOrUpdateTemplate(projectTemplate)
// 5. 复制模板文件
await copyTemplateFiles(templatePkg, targetPath)
// 6. 处理模板配置
const renderData = await processTemplateConfig(templatePkg)
// 7. 渲染模板文件
await renderTemplateFiles(targetPath, renderData)
// 8. 清理不需要的文件
await cleanupUnusedFiles(targetPath, renderData)
// 9. 显示成功信息
showSuccessMessage(targetPath)
} catch (error) {
console.error("项目创建失败:", error)
process.exit(1)
}
}模板下载与更新
typescript
class NpmPackage {
private name: string
private targetPath: string
private npmFilePath: string
constructor(options: { name: string; targetPath: string }) {
this.name = options.name
this.targetPath = options.targetPath
this.npmFilePath = path.join(this.targetPath, "node_modules", this.name)
}
async exists(): Promise<boolean> {
try {
await fse.access(this.npmFilePath)
return true
} catch {
return false
}
}
async install(): Promise<void> {
// 创建目标目录
await fse.ensureDir(this.targetPath)
// 初始化 package.json
const packageJson = {
name: "temp-template",
version: "1.0.0",
private: true
}
await fse.writeJSON(path.join(this.targetPath, "package.json"), packageJson)
// 安装模板包
await execa("npm", ["install", this.name], {
cwd: this.targetPath,
stdio: "pipe"
})
}
async update(): Promise<void> {
await execa("npm", ["update", this.name], {
cwd: this.targetPath,
stdio: "pipe"
})
}
get npmFilePath(): string {
return this.npmFilePath
}
}模板管理系统
模板包结构
text
template-react/
├── template/ # 模板文件目录
│ ├── src/
│ │ ├── main.tsx # 入口文件
│ │ └── App.tsx # 主组件
│ ├── public/
│ │ └── index.html # HTML 模板
│ ├── package.json # 项目配置 (EJS 模板)
│ ├── vite.config.ts # 构建配置
│ └── eslint.config.js # ESLint 配置 (可选)
├── questions.json # 模板配置
└── package.json # 模板包配置模板配置文件
json
{
"eslint": {
"description": "是否启用 ESLint 代码检查",
"files": ["eslint.config.js", ".eslintrc.js"],
"dependencies": {
"eslint": "^9.13.0",
"eslint-plugin-react": "^7.37.0",
"eslint-plugin-react-hooks": "^5.0.0"
},
"scripts": {
"lint": "eslint . --ext .js,.jsx,.ts,.tsx"
}
},
"typescript": {
"description": "是否启用 TypeScript 支持",
"files": ["tsconfig.json"],
"dependencies": {
"typescript": "^5.0.0",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0"
}
}
}动态模板处理
typescript
interface TemplateConfig {
[key: string]: {
description: string
files: string[]
dependencies?: Record<string, string>
scripts?: Record<string, string>
}
}
async function processTemplateConfig(
templatePkg: NpmPackage
): Promise<Record<string, any>> {
const renderData: Record<string, any> = {
projectName,
dependencies: {},
scripts: {}
}
const deleteFiles: string[] = []
const configPath = path.join(templatePkg.npmFilePath, "questions.json")
if (await fse.pathExists(configPath)) {
const config: TemplateConfig = await fse.readJSON(configPath)
for (const [key, option] of Object.entries(config)) {
const enabled = await confirm({
message: option.description || `是否启用 ${key}?`
})
renderData[key] = enabled
if (!enabled) {
deleteFiles.push(...option.files)
} else {
// 合并依赖和脚本
if (option.dependencies) {
Object.assign(renderData.dependencies, option.dependencies)
}
if (option.scripts) {
Object.assign(renderData.scripts, option.scripts)
}
}
}
}
renderData.deleteFiles = deleteFiles
return renderData
}代码生成与渲染
EJS 模板语法
json
// package.json 模板示例
{
"name": "<%= projectName %>",
"version": "1.0.0",
"description": "A <%= projectName %> project",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
<% if (eslint) { %>,
"lint": "<%= scripts.lint %>"
<% } %>
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
<% if (Object.keys(dependencies).length > 0) { %>,
<% Object.entries(dependencies).forEach(([name, version], index) => { %>
"<%= name %>": "<%= version %>"<%= index < Object.keys(dependencies).length - 1 ? ',' : '' %>
<% }); %>
<% } %>
}
}文件渲染流程
typescript
async function renderTemplateFiles(
targetPath: string,
renderData: Record<string, any>
): Promise<void> {
const files = await glob("**", {
cwd: targetPath,
nodir: true,
ignore: ["node_modules/**", ".git/**"]
})
for (const file of files) {
const filePath = path.join(targetPath, file)
try {
// 尝试作为 EJS 模板渲染
const rendered = await ejs.renderFile(filePath, renderData, {
async: true,
root: targetPath
})
await fse.writeFile(filePath, rendered)
} catch (error) {
// 如果渲染失败,保持原文件内容
console.warn(`文件 ${file} 渲染失败,保持原内容`)
}
}
}清理不需要的文件
typescript
async function cleanupUnusedFiles(
targetPath: string,
renderData: Record<string, any>
): Promise<void> {
const deleteFiles = renderData.deleteFiles || []
for (const file of deleteFiles) {
const filePath = path.join(targetPath, file)
if (await fse.pathExists(filePath)) {
await fse.remove(filePath)
console.log(`已删除: ${file}`)
}
}
}配置与扩展
CLI 配置
typescript
// cli/src/index.ts
import { Command } from "commander"
import { create } from "@guang-cli/create"
const program = new Command()
program.name("guang-cli").description("现代化前端项目脚手架工具").version("1.0.0")
program
.command("create")
.description("创建新项目")
.option("-t, --template <template>", "指定项目模板")
.option("-n, --name <name>", "项目名称")
.option("--no-eslint", "禁用 ESLint")
.option("--no-typescript", "禁用 TypeScript")
.action(async (options) => {
try {
await create(options)
} catch (error) {
console.error("创建失败:", error)
process.exit(1)
}
})
program.parse()模板扩展
typescript
// 支持自定义模板源
interface TemplateSource {
name: string
registry: string // npm registry 地址
organization?: string // 组织名
templates: TemplateChoice[]
}
const templateSources: TemplateSource[] = [
{
name: "官方模板",
registry: "https://registry.npmjs.org",
organization: "@guang-cli",
templates: [
{ name: "React", value: "template-react" },
{ name: "Vue", value: "template-vue" },
{ name: "Angular", value: "template-angular" }
]
},
{
name: "公司内网模板",
registry: "https://npm.company.com",
organization: "@company",
templates: [
{ name: "微前端", value: "template-micro-frontend" },
{ name: "组件库", value: "template-component-lib" }
]
}
]配置文件支持
typescript
// 支持 .guangclirc 配置文件
interface CliConfig {
defaultTemplate?: string
registry?: string
packageManager?: "npm" | "yarn" | "pnpm"
features?: {
eslint?: boolean
typescript?: boolean
prettier?: boolean
}
}
async function loadConfig(): Promise<CliConfig> {
const configFiles = [
".guangclirc",
".guangclirc.json",
".guangclirc.js",
"guang-cli.config.js"
]
for (const configFile of configFiles) {
const configPath = path.join(process.cwd(), configFile)
if (await fse.pathExists(configPath)) {
if (configFile.endsWith(".js")) {
return require(configPath)
} else {
return await fse.readJSON(configPath)
}
}
}
return {}
}最佳实践
1. 错误处理
typescript
class CliError extends Error {
constructor(message: string, public code: string, public details?: any) {
super(message)
this.name = "CliError"
}
}
async function handleError(error: Error): Promise<void> {
if (error instanceof CliError) {
console.error(`错误 [${error.code}]: ${error.message}`)
if (error.details) {
console.error("详细信息:", error.details)
}
} else {
console.error("未知错误:", error.message)
}
process.exit(1)
}2. 日志系统
typescript
enum LogLevel {
DEBUG = 0,
INFO = 1,
WARN = 2,
ERROR = 3
}
class Logger {
private level: LogLevel
constructor(level: LogLevel = LogLevel.INFO) {
this.level = level
}
debug(message: string, ...args: any[]): void {
if (this.level <= LogLevel.DEBUG) {
console.debug(`[DEBUG] ${message}`, ...args)
}
}
info(message: string, ...args: any[]): void {
if (this.level <= LogLevel.INFO) {
console.info(`[INFO] ${message}`, ...args)
}
}
warn(message: string, ...args: any[]): void {
if (this.level <= LogLevel.WARN) {
console.warn(`[WARN] ${message}`, ...args)
}
}
error(message: string, ...args: any[]): void {
if (this.level <= LogLevel.ERROR) {
console.error(`[ERROR] ${message}`, ...args)
}
}
}
export const logger = new Logger(
process.env.LOG_LEVEL === "debug" ? LogLevel.DEBUG : LogLevel.INFO
)3. 性能优化
typescript
// 并行处理模板下载和文件操作
async function optimizeTemplateProcessing(templates: string[]): Promise<void> {
const downloadPromises = templates.map(async (template) => {
const pkg = new NpmPackage({
name: template,
targetPath: path.join(os.homedir(), ".guang-cli-templates")
})
if (!(await pkg.exists())) {
return pkg.install()
}
return Promise.resolve()
})
await Promise.all(downloadPromises)
}
// 缓存机制
class TemplateCache {
private cacheDir: string
private maxAge: number = 24 * 60 * 60 * 1000 // 24小时
constructor() {
this.cacheDir = path.join(os.homedir(), ".guang-cli-cache")
}
async get(key: string): Promise<any> {
const cachePath = path.join(this.cacheDir, `${key}.json`)
if (await fse.pathExists(cachePath)) {
const stats = await fse.stat(cachePath)
const age = Date.now() - stats.mtime.getTime()
if (age < this.maxAge) {
return await fse.readJSON(cachePath)
}
}
return null
}
async set(key: string, data: any): Promise<void> {
await fse.ensureDir(this.cacheDir)
const cachePath = path.join(this.cacheDir, `${key}.json`)
await fse.writeJSON(cachePath, data)
}
}4. 测试策略
typescript
// 单元测试示例
import { describe, it, expect, jest } from "@jest/globals"
import { create } from "../src/create"
describe("create command", () => {
it("should create project with valid name", async () => {
const mockPrompts = {
select: jest.fn().mockResolvedValue("@guang-cli/template-react"),
input: jest.fn().mockResolvedValue("test-project"),
confirm: jest.fn().mockResolvedValue(false)
}
jest.mock("@inquirer/prompts", () => mockPrompts)
await create()
expect(mockPrompts.select).toHaveBeenCalled()
expect(mockPrompts.input).toHaveBeenCalled()
})
it("should handle invalid project name", async () => {
const mockInput = jest
.fn()
.mockResolvedValueOnce("") // 第一次输入为空
.mockResolvedValueOnce("valid-name") // 第二次输入有效
jest.mock("@inquirer/prompts", () => ({
select: jest.fn(),
input: mockInput
}))
await create()
expect(mockInput).toHaveBeenCalledTimes(2)
})
})常见问题
Q1: 模板下载失败怎么办?
A: 检查网络连接和 NPM 源配置:
bash
# 检查网络连接
ping registry.npmjs.org
# 查看当前 NPM 源
npm config get registry
# 切换到淘宝源
npm config set registry https://registry.npmmirror.com
# 或者使用代理
npm config set proxy http://proxy.company.com:8080Q2: 如何处理私有 NPM 包?
A: 配置 NPM 认证信息:
bash
# 登录私有 NPM 仓库
npm login --registry=https://npm.company.com
# 或者在项目中配置 .npmrc
echo "@company:registry=https://npm.company.com" > .npmrc
echo "//npm.company.com/:_authToken=YOUR_TOKEN" >> .npmrcQ3: 模板渲染失败如何处理?
A: 检查模板语法和文件编码:
typescript
// 添加详细的错误信息
try {
const rendered = await ejs.renderFile(filePath, renderData)
} catch (error) {
console.error(`模板渲染失败: ${filePath}`)
console.error(`错误信息: ${error.message}`)
console.error(`模板数据:`, renderData)
// 保留原文件
console.warn(`保留原文件: ${filePath}`)
}Q4: 如何调试 CLI 工具?
A: 使用 Node.js 调试工具和日志:
bash
# 使用 Node.js 调试器
node --inspect-brk ./bin/cli.js create
# 设置调试日志
DEBUG=guang-cli:* npx @guang-cli/cli create
# 查看详细输出
npx @guang-cli/cli create --verboseQ5: 性能优化建议?
A:
- 模板缓存: 将下载的模板缓存到本地,避免重复下载
- 并行处理: 多个文件操作可以并行执行
- 增量更新: 只更新发生变化的文件
- 压缩传输: 使用压缩格式传输模板文件
typescript
// 启用压缩
await execa("npm", ["install", "--prefer-offline", "--no-audit"], {
cwd: this.targetPath,
env: {
...process.env,
npm_config_cache: path.join(os.homedir(), ".npm-cache")
}
})版本更新记录
v1.2.0 (2024-12-04)
新增功能
- ✅ 支持 TypeScript 模板项目
- ✅ 添加配置文件支持 (.guangclirc)
- ✅ 支持自定义 NPM 源配置
- ✅ 添加模板缓存机制
优化改进
- 🚀 优化模板下载速度,支持并行处理
- 🔧 改进错误处理和用户提示信息
- 📊 添加详细的调试日志输出
- 🛡️ 增强项目名验证规则
问题修复
- 🐛 修复 Windows 系统路径兼容性问题
- 🐛 修复模板渲染失败时文件丢失问题
- 🐛 修复网络异常时的重试机制
v1.1.0 (2024-11-18)
新增功能
- ✅ 支持 ESLint 可选配置
- ✅ 添加模板配置文件 (questions.json)
- ✅ 支持动态依赖管理
- ✅ 添加项目创建成功提示
优化改进
- 🚀 优化用户交互流程
- 🔧 改进模板渲染性能
- 📋 完善命令行参数支持
v1.0.0 (2024-11-17)
初始版本
- ✅ 基础 create 命令实现
- ✅ 支持 React/Vue 模板选择
- ✅ 项目目录冲突处理
- ✅ 基础模板下载和复制
- ✅ EJS 模板渲染支持
附录
相关资源
贡献指南
欢迎提交 Issue 和 Pull Request 来改进这个脚手架工具。
许可证
MIT License - 详见 LICENSE 文件